首页/技术教程/解决了!Bun + Windows 环境下 npm 包解压的诡异 Bug

今天跟大家分享一个在 Bun + Windows 环境下解压 npm 包时遇到的诡异问题,以及我是如何一步步找到解决方案的。

问题的起因

我在开发 befly 框架(为Bun专属定制的后端Api接口开发框架)的 CLI 工具时,需要实现一个功能:从 npm 仓库下载 befly-admin 包,解压后同步其中的 internal 目录到项目中。

看起来很简单对吧?下载 tarball,解压,复制文件,完事儿。

但事情远没有这么简单。

第一次尝试:pacote

最开始,我选择了 npm 官方的 pacote 包。这玩意儿是 npm CLI 底层使用的工具,专门用来处理包的下载和解压。

理论上,官方出品,肯定靠谱啊。

import pacote from "pacote";

await pacote.extract("befly-admin@latest", targetDir);

就这么简单一行代码。

然后,运行。

报错了。

什么 ENOENTpermission denied、各种莫名其妙的错误。

我开始怀疑人生。

查了半天文档,发现 pacote 在 Bun 运行时下有兼容性问题。它依赖 Node.js 的一些内部实现,而 Bun 虽然兼容 Node.js API,但在某些底层细节上还是有差异。

特别是在 Windows 平台上,这种差异被放大了。

第一次尝试,失败。

第二次尝试:tar

既然 pacote 不行,那我自己下载 tarball,然后用 tar 包解压总可以了吧?

tar 是 Node.js 生态中最流行的 tar 解压库,npm 自己也在用。

import { extract } from "tar";

// 下载 tarball
const response = await fetch(tarballUrl);
await Bun.write(tarballPath, await response.arrayBuffer());

// 解压
await extract({
    file: tarballPath,
    cwd: extractDir
});

看起来完美。

运行。

解压成功!

我打开解压目录一看…

只有目录,没有文件。

所有的目录结构都创建了,但文件全是空的,0 字节。

这是什么鬼?

开始排查问题

我不信邪,开始一步步排查:

1. 检查 tarball 本身

首先确认 tarball 文件是否正常下载:

const stats = await stat(tarballPath);
console.log(`tarball 大小: ${stats.size} 字节`); // 49KB,正常

tarball 文件没问题。

2. 检查 tar 包的文件列表

import { list } from "tar";

await list({
    file: tarballPath,
    onentry: (entry) => {
        console.log(`${entry.path} size=${entry.size}`);
    }
});

输出:

package/src/plugins/internal/http.ts size=2535
package/src/plugins/internal/router.ts size=1371
...

文件列表正常,文件大小也正常。

3. 测试基础的 WriteStream

我怀疑是 Bun 的 fs.WriteStream 有问题:

import { createWriteStream } from "node:fs";

const stream = createWriteStream(testFile);
stream.write("test content");
stream.end();

测试结果:正常

4. 测试 Gzip 解压

我又测试了 gzip 流解压:

import { createGunzip } from "node:zlib";
import { pipeline } from "node:stream/promises";

await pipeline(createReadStream(tarballPath), createGunzip(), createWriteStream(tarPath));

测试结果:正常

tar 文件被正确解压出来了,大小正常。

问题的根源

经过一系列测试,我终于定位到了问题的根源:

tar npm 包与 Bun 运行时在 Windows 平台上存在兼容性问题。

具体来说:

  1. tar 包内部使用了 minipass 流库
  2. minipass 是为 Node.js 运行时专门设计的
  3. Bun 的流实现虽然兼容 Node.js API,但在某些边界情况下行为不一致
  4. 在 Windows 平台上,文件权限映射(Unix Windows ACL)存在问题
  5. 导致 tar 包在写入文件内容时被静默跳过

目录创建是同步操作,所以目录结构正常。

文件写入是异步的,而且依赖复杂的流状态机,在 Bun + Windows 组合下失败了。

两个临时方案

找到原因后,我有两个临时方案:

方案 1:使用系统 tar 命令

Windows 10+ 自带 tar 命令,直接调用系统命令:

import { spawnSync } from "node:child_process";

const result = spawnSync("tar", ["-xzf", tarballPath, "-C", extractDir], {
    shell: true
});

优点

  • 稳定可靠
  • 性能好(原生实现)

缺点

  • 依赖系统环境
  • 不是纯 JavaScript 方案

方案 2:等 Bun 修复

给 Bun 团队提 issue,等他们修复兼容性问题。

优点

  • 从根本上解决问题

缺点

  • 不知道要等多久
  • 可能永远不会修复

这两个方案都不够优雅。

最终方案:fast-extract

就在我准备使用系统 tar 命令时,我想到:既然 tar 包有问题,那生态中有没有其他的解压库呢?

搜索一番后,我发现了 fast-extract

这是一个轻量级的解压库,支持多种格式:tar, tar.gz, tar.bz2, tar.xz, tgz, zip。

抱着试试看的态度,我写了个测试:

import extract from "fast-extract";

await extract(tarballPath, extractDir, { strip: 0 });

运行。

成功了!

文件全部正确解压,大小正常,内容完整。

我又在 Windows 和 Linux 上都测试了一遍,完美工作。

为什么 fast-extract 可以?

我研究了一下 fast-extract 的实现,发现它和 tar 包的主要区别在于:

  1. 不依赖 minipass:使用更轻量的流处理方式
  2. 更好的跨平台支持:专门处理了 Windows 平台的兼容性问题
  3. 更简单的实现:没有 tar 包那么多历史包袱

最关键的是,它在 Bun + Windows 组合下工作正常。

最终代码

简化后的代码非常干净:

import { join, relative } from "pathe";
import { tmpdir } from "node:os";
import { rm, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import extract from "fast-extract";

async function syncAdmin() {
    const tempDir = join(tmpdir(), `befly-admin-${Date.now()}`);
    const tarballPath = join(tempDir, "package.tgz");
    const extractDir = join(tempDir, "extracted");

    try {
        await mkdir(tempDir, { recursive: true });

        // 1. 获取并下载最新版本
        const metaData = await fetch("https://registry.npmmirror.com/befly-admin/latest").then((res) => res.json());

        await Bun.write(tarballPath, await fetch(metaData.dist.tarball).then((res) => res.arrayBuffer()));

        // 2. 解压
        await extract(tarballPath, extractDir, { strip: 0 });

        // 3. 同步文件
        const srcDir = join(extractDir, "package", "src");
        // ... 后续的文件同步逻辑

        // 4. 清理临时目录
        await rm(tempDir, { recursive: true, force: true });
    } catch (error) {
        // 错误处理
    }
}

从原来的 149 行精简到 121 行,减少了 19% 的代码量。

写在最后

技术开发就是这样,看似简单的需求,实际做起来可能遇到各种奇葩问题。

重要的是保持耐心,一步步排查,总能找到解决方案。

这次的经历也提醒我:选择工具时,不仅要看功能和流行度,还要考虑具体的运行环境和兼容性

希望这篇文章能帮到遇到类似问题的朋友。

如果你也在用 Bun,或者遇到过类似的解压问题,欢迎留言交流!


相关链接

大家有什么技术问题,也欢迎加我微信:chensuiyime 交流~